--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7aeaf41555deb263bb8c260e5186e4be8497c9cd
Parents : 35fe207
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T15:18:03-05:00
feat(android): update BLE permissions handling
Changes
2 files changed, 127 insertions(+), 27 deletions(-)
Diff
diff --git a/android/app/src/main/java/org/able/BLE.java b/android/app/src/main/java/org/able/BLE.java
index 9dabcfb4..05d042b0 100644
--- a/android/app/src/main/java/org/able/BLE.java
+++ b/android/app/src/main/java/org/able/BLE.java
@@ -1,5 +1,7 @@
package org.able;
+import android.Manifest;
+import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
@@ -19,14 +21,22 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
+import android.os.Build;
import android.util.Log;
+import androidx.core.content.ContextCompat;
+
import java.util.List;
/**
* Android BLE helper used by MeshChatX's Chaquopy able package.
* Context is injected from MainActivity instead of Kivy PythonActivity.
+ *
+ * Runtime BLUETOOTH_SCAN / BLUETOOTH_CONNECT checks gate every privileged call.
+ * SuppressLint covers lint's MissingPermission analysis, which does not always
+ * follow those helpers across API-level branches.
*/
+@SuppressLint("MissingPermission")
public class BLE {
private static final String TAG = "BLE-meshchatx";
@@ -77,13 +87,38 @@ public class BLE {
);
}
+ private boolean hasConnectPermission() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ return true;
+ }
+ return ContextCompat.checkSelfPermission(mContext, Manifest.permission.BLUETOOTH_CONNECT)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ private boolean hasScanPermission() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ return true;
+ }
+ return ContextCompat.checkSelfPermission(mContext, Manifest.permission.BLUETOOTH_SCAN)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
public BluetoothAdapter getAdapter(int enableBtCode) {
if (mBluetoothAdapter == null) {
showError("Device does not support Bluetooth Low Energy.");
return null;
}
- if (!mBluetoothAdapter.isEnabled()) {
- showError("BLE adapter is not enabled");
+ if (!hasConnectPermission()) {
+ showError("Bluetooth connect permission is not granted.");
+ return null;
+ }
+ try {
+ if (!mBluetoothAdapter.isEnabled()) {
+ showError("BLE adapter is not enabled");
+ return null;
+ }
+ } catch (SecurityException e) {
+ showError("Bluetooth connect permission is not granted.");
return null;
}
return mBluetoothAdapter;
@@ -99,30 +134,49 @@ public class BLE {
public void startScan(int enableBtCode, List<ScanFilter> filters, ScanSettings settings) {
Log.d(TAG, "startScan");
+ if (!hasScanPermission()) {
+ showError("Bluetooth scan permission is not granted.");
+ mPython.on_scan_started(false);
+ return;
+ }
BluetoothAdapter adapter = getAdapter(enableBtCode);
if (adapter == null) {
return;
}
- if (mBluetoothLeScanner == null) {
- mBluetoothLeScanner = adapter.getBluetoothLeScanner();
- }
- if (mBluetoothLeScanner != null) {
- mScanning = false;
- mBluetoothLeScanner.startScan(filters, settings, mScanCallback);
- } else {
- showError("Could not get BLE Scanner object.");
+ try {
+ if (mBluetoothLeScanner == null) {
+ mBluetoothLeScanner = adapter.getBluetoothLeScanner();
+ }
+ if (mBluetoothLeScanner != null) {
+ mScanning = false;
+ mBluetoothLeScanner.startScan(filters, settings, mScanCallback);
+ } else {
+ showError("Could not get BLE Scanner object.");
+ mPython.on_scan_started(false);
+ }
+ } catch (SecurityException e) {
+ showError("Bluetooth scan permission is not granted.");
mPython.on_scan_started(false);
}
}
public void stopScan() {
- if (mBluetoothLeScanner != null) {
+ if (mBluetoothLeScanner == null) {
+ return;
+ }
+ if (!hasScanPermission()) {
+ showError("Bluetooth scan permission is not granted.");
+ return;
+ }
+ try {
Log.d(TAG, "stopScan");
mBluetoothLeScanner.stopScan(mScanCallback);
if (mScanning) {
mScanning = false;
mPython.on_scan_completed();
}
+ } catch (SecurityException e) {
+ showError("Bluetooth scan permission is not granted.");
}
}
@@ -155,13 +209,21 @@ public class BLE {
public void connectGatt(BluetoothDevice device, boolean autoConnect) {
Log.d(TAG, "connectGatt");
+ if (!hasConnectPermission()) {
+ showError("Bluetooth connect permission is not granted.");
+ return;
+ }
if (mBluetoothGatt == null) {
- mBluetoothGatt = device.connectGatt(
- mContext,
- autoConnect,
- mGattCallback,
- BluetoothDevice.TRANSPORT_LE
- );
+ try {
+ mBluetoothGatt = device.connectGatt(
+ mContext,
+ autoConnect,
+ mGattCallback,
+ BluetoothDevice.TRANSPORT_LE
+ );
+ } catch (SecurityException e) {
+ showError("Bluetooth connect permission is not granted.");
+ }
} else {
Log.d(TAG, "BluetoothGatt exists, call closeGatt() before reconnecting");
}
@@ -169,9 +231,18 @@ public class BLE {
public void closeGatt() {
Log.d(TAG, "closeGatt");
- if (mBluetoothGatt != null) {
+ if (mBluetoothGatt == null) {
+ return;
+ }
+ if (!hasConnectPermission()) {
+ showError("Bluetooth connect permission is not granted.");
+ return;
+ }
+ try {
mBluetoothGatt.close();
mBluetoothGatt = null;
+ } catch (SecurityException e) {
+ showError("Bluetooth connect permission is not granted.");
}
}
@@ -273,20 +344,44 @@ public class BLE {
byte[] data,
int writeType
) {
- if (characteristic.setValue(data)) {
- if (writeType != 0) {
- characteristic.setWriteType(writeType);
- }
- return mBluetoothGatt != null && mBluetoothGatt.writeCharacteristic(characteristic);
+ if (!hasConnectPermission() || mBluetoothGatt == null) {
+ return false;
+ }
+ if (!characteristic.setValue(data)) {
+ return false;
+ }
+ if (writeType != 0) {
+ characteristic.setWriteType(writeType);
+ }
+ try {
+ return mBluetoothGatt.writeCharacteristic(characteristic);
+ } catch (SecurityException e) {
+ showError("Bluetooth connect permission is not granted.");
+ return false;
}
- return false;
}
public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) {
- return mBluetoothGatt != null && mBluetoothGatt.readCharacteristic(characteristic);
+ if (!hasConnectPermission() || mBluetoothGatt == null) {
+ return false;
+ }
+ try {
+ return mBluetoothGatt.readCharacteristic(characteristic);
+ } catch (SecurityException e) {
+ showError("Bluetooth connect permission is not granted.");
+ return false;
+ }
}
public boolean readRemoteRssi() {
- return mBluetoothGatt != null && mBluetoothGatt.readRemoteRssi();
+ if (!hasConnectPermission() || mBluetoothGatt == null) {
+ return false;
+ }
+ try {
+ return mBluetoothGatt.readRemoteRssi();
+ } catch (SecurityException e) {
+ showError("Bluetooth connect permission is not granted.");
+ return false;
+ }
}
}
diff --git a/tests/frontend/Interface.test.js b/tests/frontend/Interface.test.js
index 830409b1..79a9884e 100644
--- a/tests/frontend/Interface.test.js
+++ b/tests/frontend/Interface.test.js
@@ -93,10 +93,15 @@ describe("Interface.vue", () => {
it("action buttons and dropdown have shrink-0 to prevent squashing", () => {
const wrapper = mountInterface();
- const actionsCol = wrapper.find(".absolute.top-2.right-2.z-20.flex.flex-row.items-center.gap-1.sm\\:static");
+ const actionsCol = wrapper.find(
+ ".flex.flex-row.items-center.gap-1.sm\\:relative.sm\\:z-auto.sm\\:flex.sm\\:flex-row.sm\\:gap-2.sm\\:items-center.sm\\:shrink-0.sm\\:justify-end"
+ );
+ expect(actionsCol.exists()).toBe(true);
expect(actionsCol.classes()).toContain("sm:shrink-0");
const btn = wrapper.find('button[title="interface.disable"]');
expect(btn.classes()).toContain("shrink-0");
+ const dropdown = wrapper.find(".relative.z-50.shrink-0");
+ expect(dropdown.exists()).toBe(true);
});
it("detail-value has break-all for long addresses", () => {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────